Skip to content

[New Model][Nvidia] Add SM12x support for DeepSeek V4 Flash with essential fixes#41834

Open
jasl wants to merge 174 commits into
vllm-project:mainfrom
jasl:codex/ds4-sm120-min-enable
Open

[New Model][Nvidia] Add SM12x support for DeepSeek V4 Flash with essential fixes#41834
jasl wants to merge 174 commits into
vllm-project:mainfrom
jasl:codex/ds4-sm120-min-enable

Conversation

@jasl

@jasl jasl commented May 6, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR enables DeepSeek V4 Flash on SM120/SM121 Blackwell client hardware by carrying the SM12x fallback and tuning stack needed for the current vLLM V1 path. It is intended for RTX PRO 6000 Blackwell Workstation Edition, RTX 5090-class SM120, and GB10 / DGX Spark SM121 users who cannot use SM100-only TMEM / tcgen05 kernels.

This branch is reconciled on top of the merged #43477 and provides the stock-deps path: DeepSeek V4 on SM120/121 that builds and serves on released FlashInfer / DeepGEMM wheels, complementing #43477's route that needs the unreleased FlashInfer #3395 + DeepGEMM #324 dependency branches. It is kept synced onto current upstream/main and now also carries DSpark spec-decode support (self-drafting block-5) alongside the default MTP2 path. Latest validated head is tag sm120-pr-41834-stable-preview-20260705 (9f18be7630). See Update 2026-07-05 (a 2-pass finer-histogram top-k that ~halves the reads on the GB10 long-context indexer path), Update 2026-07-04 (a GB10 unified-memory Triton recompile leak → hard-freeze fix + an fp8-einsum alignment recovery, reported by @GanyX19) and Update 2026-07-03 (DSpark, the V2 padded-Q OOM fix #26, the persistent_topk GB10 long-context fix, llama-benchy baselines) below, and the earlier dated updates for history.

Change footprint — model kernels vs. core-vLLM touch points

The branch splits cleanly into model/kernel code and a small set of core-vLLM integration points (116 files, +15.5k/−0.4k, of which ~+3.5k is tests):

  • DeepSeek-V4 model + SM12x kernels (~49 files, ~+10.2k) — the enablement itself. Everything under vllm/models/deepseek_v4/** plus the SM12x sparse-MLA decode / indexer / DeepGEMM kernels that live in shared dirs (v1/attention/backends/mla/sparse_mla_kernels.py, model_executor/layers/sparse_attn_indexer.py, v1/attention/backends/mla/{indexer,sparse_swa}.py, utils/deep_gemm.py, kernels/mhc/tilelang.py), the new DSv4 reasoning parser / tokenizer, and device tuning JSONs.
  • C128A metadata device→host sync removed (models/deepseek_v4/sparse_mla.py, perf, 2026-06-26) — _c128a_effective_topk_width now takes the max position from the CPU-side CommonAttentionMetadata.max_seq_len instead of a per-step int(positions.max().item()) device sync, dropping a launch-stream stall on every C128A metadata step (reported via gdb native stacks by a GB10/TP4 user). Decode is identical (max_seq_len-1 == positions.max()); only chunked prefill sees a safe, slightly-wider 128-aligned top-k.
  • Core-vLLM integration (~36 files, +1.8k/−0.2k) — the hooks below. Almost all are gated by model architecture / quant config / an env flag and are inert for other models.
Subsystem Files (≈ lines) What it does
KV-cache core single_type_kv_cache_manager.py (+243), kv_cache_coordinator.py (+67), kv_cache_manager.py (+60), sched/scheduler.py (+1) prefix-cache correctness for DSv4 sparse-MLA + MTP: an MLA cache-manager with prompt-block protection, a hybrid-coordinator cache_blocks tail-block-reuse rewrite. (Our earlier block_pool stale-hash reset is dropped in the reconcile — subsumed by upstream's own unconditional reset, which arrived via the upstream/main merge.)
MTP spec-decode v1/spec_decode/llm_base_proposer.py (+173) DSv4 MTP probabilistic draft sampling + per-step MTP-layer routing in the shared proposer base
MoE quantization fused_moe.py (+65), oracle/mxfp4.py (+43), routed_experts.py (+33), experts/flashinfer_cutlass_moe.py (+27), quantization/mxfp4.py (+12), oracle/nvfp4.py (+1) MXFP4 / NVFP4 backend selection; the one-line NVFP4 fix (FLASHINFER_CUTLASS into the SwiGLU-clamp allow-list) lets DSv4-Flash-NVFP4 serve
FP8 / Marlin GEMM quantization/utils/fp8_utils.py (+99), linear/scaled_mm/{cutlass,marlin}.py (+45/+16), csrc/.../marlin_moe_wna16/ops.cu (+10, the only C++) SM12x e8m0→fp32 upcast + Marlin MoE SM12.0a cudagraph hardening (mirrors open upstream #43730 / #43722)
cudagraph / compile / config config/vllm.py (+44), compilation/breakable_cudagraph.py (+22), passes/utility/fix_functionalization.py (+12), config/compilation.py (+11) breakable-cudagraph auto-enable gate (MiniMax-only; DSv4 deliberately excluded), DSv4 custom-op defunctionalization + splitting-op registration
OpenAI entrypoints / parsers chat_completion/protocol.py (+101), serve/render/serving.py (+28), tool_parsers/structural_tag_registry.py (+16), chat_utils.py (+11), engine/protocol.py (+9), chat_completion/{serving,batch_serving}.py (+8/+6), reasoning/__init__.py (+4) expose DSv4 API semantics — reasoning_content / thinking param / tool-call streaming (jasl#19 instruction-following)
Kernel warmup model_executor/warmup/kernel_warmup.py (+617) additive DSv4 warmup (D512-split prefill precompile + MTP) to avoid JIT-during-inference wedges
Weight loading weight_utils.py (+43), default_loader.py (+16) fast-safetensors weight filter + EP-skip (lowers DSv4 load overhead on GB10)
env / utils envs.py (+63), utils/flashinfer.py (+16), utils/import_utils.py (+9), v1/worker/{gpu_model_runner,ubatch_utils}.py (+12/+12) VLLM_DEEPSEEK_V4_* flags + has_cutedsl / has_flashinfer_trtllm_sparse_mla probes

Two notes for review:

  • The most invasive generic edits were removed in the 2026-06-21 audit cleanup (below): the scheduler now carries a single +1-line change (the prefill-fairness heuristics were dropped) and the prefix-cache write-fence is gone.
  • A few hooks do touch code paths shared with non-DSv4 models and are the ones worth a closer look: the kv_cache_coordinator cache_blocks rewrite (affects hybrid-KV models; validated ≥ prior behavior), the MTP proposer base-class change, and the OpenAI-entrypoint plumbing. Everything else (MoE oracle, fp8_utils, cudagraph gate, warmup, envs) is arch / quant / env-gated and inert for other models.

Duplicate-work check

Open PR search was refreshed on 2026-06-12 for SM120 / SM12x / DeepSeek V4 / GB10 terms. The nearest open PRs are related but not duplicates:

PR Difference
#43477 Merged 2026-06-22. Enables DeepSeek V4 + GLM-5.1 on SM120 via the FlashInfer-SM120 sparse-MLA route, but on its merged form requires the unreleased FlashInfer #3395 + DeepGEMM #324 dependency branches — on released/stock wheels its SM12x path raises at model construction (and the pinned DeepGEMM ref asserts on SM120). This PR is now reconciled on top of #43477 (merge 42657aca65) and carries the stock-deps DSv4 SM120/121 path that runs on released wheels, complementing #43477's fork-deps route. See Update 2026-06-23 — #43477 reconciliation below.
#40929 Earlier WIP Triton fallback effort. This PR is the maintained replacement branch with the broader scheduler, prefix-cache, parser, quant, warmup, and harness-validated fixes carried forward.
#42856 Focused workspace-bound fix that explicitly depends on / references this PR; it is a subset-style bugfix, not the full DeepSeek V4 SM12x enablement branch.

Fixed preview tags

These tags are in jasl/vllm and give users stable pins while the PR is still moving:

Tag Commit Use
sm120-pr-41834-stable-preview-20260705 9f18be7630 latest validated head — the 20260704 head + a 2-pass finer-histogram top-k for the GB10 long-context indexer path (see Update 2026-07-05): 2 full-row reads instead of the streaming radix's 4, exact (falls back to streaming if the pivot bin overflows). Validated GB10: bit-exact vs torch.topk (64K–1M × spread/dense/ties), 2.43× single-row kernel at 1M, arthur 434K recall 2/2.
sm120-pr-41834-stable-preview-20260704 b43470e871 the 20260703 head + @GanyX19's two GB10 kernel fixes (see Update 2026-07-04): MQA-logits + tf32-prenorm per-shape constexpr→runtime (stops the unbounded Triton recompile that leaks unified memory ~5–6 GB/h/node and hard-freezes GB10 under load), and the fp8-einsum tl.multiple_of(16) alignment recovery (~24% single-stream decode at long context). GB10 validated: GSM8K-100 0.99, arthur 16/16, decode-neutral at ≤32K; DSv4-Flash MTP2 + DSpark llama-benchy re-run normal.
sm120-pr-41834-stable-preview-20260703 444fe3ac8b adds DSpark spec-decode (self-drafting block-5), several upstream/main syncs since 06-26 (307 commits, incl. MRV2 scheduler #46974 + spec-decode int64 #47383), the V2 padded-Q OOM fix (#26) (which also reopened V2 long-context recall), and an exact non-cooperative persistent_topk for <128 KB-smem parts (fixes GB10 ≥400k long-context hard-fail). GB10 SM121 validated: GSM8K-200 0.965, arthur 434k 2/2 + conc12 24/24; DSv4-Flash MTP2 and DSpark both serve. llama-benchy 446dd42f baselines in Update 2026-07-03 below.
sm120-pr-41834-stable-preview-20260626 c766cbc6ff synced onto upstream/main (198 commits since the #43477 merge; our NVFP4 FLASHINFER_CUTLASS-clamp fix landed upstream as #46492 → fork patch dropped) + the C128A metadata device-sync removal. 6 conflicts resolved; upstream's new cooperative_topk (#43008) gated off SM12x (capability family 120) to keep the validated decode path byte-identical. Validated dual-arch — RTX SM120 GSM8K-200 0.97 + #19 PASS; GB10 SM121 GSM8K-200 0.945 + arthur 64/64 + Forum53 PASS + llama-benchy prefill +80% / decode flat. See Update 2026-06-26 below.
sm120-pr-41834-stable-preview-20260623 f7b4b425b0 reconciled on top of the now-merged #43477 (merge 42657aca65 of upstream/main), keeping the stock-deps DSv4 SM120/121 path that runs on released wheels. Two reconcile regressions fixed — DeepGEMM no longer auto-enabled on SM120 (a94657e601, the pinned ref asserts), and #43477's prefill-SWA launch is gated + the kernel OOB clamped (f7b4b425b0). Validated dual-arch (see Update 2026-06-23 — #43477 reconciliation below).
sm120-pr-41834-stable-preview-20260622b 5ba0f19f02 pre-reconcile head: the 2026-06-21 audit head + two long-context (256k+) crash fixes — (1) packed-prefill output now sliced symmetrically with the query under MTP/cudagraph padding (fixes output.size(0)==num_tokens (84 vs 83) when VLLM_DEEPSEEK_V4_FLASHINFER_SM120_PREFILL=1), (2) MTP draft logits cast to float32 before top-k/top-p sampling (fixes an engine-killing assertion on any MTP + non-greedy sampling, gate-independent). See Update 2026-06-22 below.
sm120-pr-41834-stable-preview-20260621 72261a7af149fa5d3fe2ed2b9956e92590731012 post-audit cleanup head — breakable-cudagraph default OFF (MiniMax-only gate), long-context recall fixed by the int64 block-offset cast (the redundant write-fence + scheduler prefill-fairness heuristics + NVFP4 b12x lever removed), on top of the jasl#19 + #45309-revert correctness fixes. Validated metrics-flat on SM120 + SM121.
sm120-pr-41834-stable-preview-20260620 a743ef5dfbd16cad0b9a628773c0c1d1841f1790 prior head (write-fence / COW recall approach, since superseded by the int64-cast fix)
sm120-pr-41834-stable-preview-20260612075245 f32247a5a695fa8979d61837bf6b87da897dcb7d earlier validated rebased PR branch preview
sm120-pr-41834-fallback-before-replacement-20260612053720 5d1584e2de2b3c64540e70dfc370b0211eb6b2fc fallback tag for the old PR head before branch replacement

Update 2026-07-05 — 2-pass finer-histogram top-k for the GB10 long-context indexer path

Head 9f18be7630 (tag sm120-pr-41834-stable-preview-20260705). The force_noncooperative long-context path (seq_len > 32768 on <128 KB-smem parts like GB10) used the exact single-CTA streaming radix, which re-reads the full row 4 times (once per MSD-radix round) — measurably the dominant cost of the sparse-indexer top-k at very long context (a GB10/1M user reported a single ~1M prefill monopolizing the engine ~110 s).

Replaced it with a 2-pass variant of the existing histogram_2048_topk (persistent_topk.cuh), via a new CacheBins template flag: Phase 1 builds the 2048-bin histogram, Phase 2 re-reads from global (instead of the per-thread register cache that caps the 1-pass path at ~8 K) and collects, Phase 3 exactly refines the buffered pivot bin. 2 full-row reads instead of 4. The finer 2048 bins keep the pivot bin small (~seq/2048 ≈ 488 at 1M, well under the 3708-entry tie buffer), so it stays single-CTA and 99 KB-smem-safe. If the pivot bin overflows the buffer (fp16 bins clustering — e.g. thousands of exactly-equal logits), it falls back to the streaming radix, so it is provably exact for any distribution / seq_len. ≥128 KB devices are unaffected (they take the cooperative radix path).

Validated (GB10 SM121):

  • Bit-exact vs torch.topk across 64K–1M × {randn-spread, clustered dense-pivot, >4096 exact-ties} — value multiset identical, no dups, fallback exercised and exact.
  • Single-row kernel time vs the streaming radix: 64K 0.038 vs 0.059 ms, 256K 0.078 vs 0.159, 512K 0.128 vs 0.291, 1M 0.229 vs 0.557 ms = 2.43× (the win grows with context — it is read-bound). Only the rare overflow/dense-pivot case is ~12 % slower (histogram pass + streaming fallback).
  • Serve: DSv4-Flash at max_model_len=524288, arthur needle-recall at ~434K = 2/2, no oversubscribe/errors — correct in the real indexer.

This is a ~2.4× speedup on the top-k; the end-to-end long-context prefill impact depends on how much of the prefill step the top-k dominates (workload-dependent, characterization in progress with the reporting user).

Update 2026-07-04 — GB10 Triton recompile-leak / hard-freeze fix + fp8-einsum alignment recovery (thanks @GanyX19)

Head b43470e871 (tag sm120-pr-41834-stable-preview-20260704) = the 20260703 head + two kernel fixes reported by @GanyX19 from running this branch under sustained load on GB10 / DGX Spark (sm_121), both in vllm/models/deepseek_v4/nvidia/ops/:

  1. Per-shape constexpr Triton recompilation → host-memory leak → hard-freeze. The three MQA-logits kernels in sm12x_mqa.py (and the _tf32_hc_prenorm_gemm_kernel) declared per-request-varying values (num_q/seq_len_kv, num_rows/logits_width, stride_lm; stride_outs/stride_sqs) as tl.constexpr. Triton caches a distinct compiled kernel per distinct value for the process lifetime; on GB10 the compile cache lives in the same unified memory as the model, so it grows unbounded (~5–6 GB/h/node under production-like load) until the host hard-freezes. These values are used only in boundary masks and address arithmetic — never as a tl.arange/range extent (verified) — so they're now plain runtime args (Triton specializes only on divisibility). stride_ln (=1) and all tile/shape constexprs stay. In a controlled A/B the pre-fix build even logged _tf32_hc_prenorm_gemm_kernel JIT-compiling during inference and then died with EngineDeadError under the benchmark — the recompile pathology biting live; the fixed build ran the same load clean.
  2. fp8-einsum missing alignment hint → −24% decode. fp8_einsum.py already keeps the group strides runtime (to avoid the same leak) but without a tl.multiple_of hint, which drops them to divisibility=1 and narrows the load — ~24% single-stream decode at 256K per @GanyX19's bisection. Restored tl.multiple_of(_, 16) on a_stride_group / a_scale_stride_group (both provably ÷16: a_stride_group = T·hidden, hidden % 128 == 0; a_scale_stride_group via align(T,4)·32) in both the BF16 and fused-quant einsum kernels. a_scale_stride_hidden / tf32 store strides are not hinted (not ÷16-provable — a false multiple_of corrupts memory).

Validated (GB10 SM121, 2-node TP=2): GSM8K-100 0.99, arthur needle-recall 16/16 — the alignment hints don't corrupt. Decode is throughput-neutral at the ≤32K benchmark sizes (the −24%/recovery is a 256K single-stream effect not exercised by the standard bench); the host-memory-leak → freeze fix is the headline win, and it's decode-neutral, so it's a clean take. The kernels are Triton (@triton.jit, pure Python) — no C++ rebuild.

llama-benchy re-run on b43470e871 (same pinned standard as below, 446dd42f):

DSv4-Flash + MTP2 DSv4-Flash-DSpark
pp2048 prefill (t/s) @ d8k/16k/32k 1263 / 1198 / 1095 1278 / 1218 / 1077
tg128 decode (t/s) @ d8k/16k/32k 41.3 / 38.1 / 32.7 34.4 / 33.5 / 36.5

Consistent with the 20260703 numbers below (within GB10 run-to-run variance) — the kernel fixes are decode-neutral.

Update 2026-07-03 — DSpark spec-decode, upstream syncs, V2 padded-Q + GB10 long-context fixes, fresh llama-benchy baselines

Latest validated head 444fe3ac8b (tag sm120-pr-41834-stable-preview-20260703), 307 commits past the 06-26 tag. Highlights:

  • Kept synced onto upstream/main across several merges (latest picks up the Model-Runner-V2 scheduler req-slot fix [BugFix][MRV2] Ensure all req slots are accounted for when scheduling #46974 and the MRV2 spec-decode int32→int64 block-verify overflow fix [Bugfix][Model Runner V2][Spec Decode] Fix int32 offset overflow in block verification kernels #47383). DSv4 is MoE so it stays on the V1 runner by default (upstream [ModelRunner V2] Enable by default for all dense models #44443 made V2 the default only for dense models); V2 is validated and reachable via VLLM_USE_V2_MODEL_RUNNER=1 but not the default.
  • DSpark spec-decode support (self-drafting, block size 5) landed alongside the existing MTP2 path — see Running DSpark below.
  • V2 padded-Q OOM fix (Add CUDA graph-based all reduce launcher #26) merged: the fused DSv4 qnorm-rope-kv-insert op now writes into a caller-owned, pre-reserved padded-Q scratch buffer instead of allocating per-forward. This removes the V2-runner OOM at long-context concurrency, and as a side effect resolved the V2 long-context recall collapse we had previously attributed to a cudagraph-replay bug — it was memory-pressure-induced (V2 recall 16/16 @28k conc8 post-fix, was 3/16). The V1 default runner is unaffected (byte-identical KV, no regression).
  • Exact non-cooperative persistent_topk for <128 KB-smem parts (GB10): the sparse-indexer top-k's cooperative-radix "large" path (seq_len > 32768) needs all CTAs co-resident and falls back to a path that asserts ≥128 KB smem/block — which GB10 (SM121, ~99 KB smem) violates, hard-failing long-context serving ≳400k. Fixed by gating on smem capacity (not capability family — RTX 50-series is also family-120 with ~100 KB) and routing those parts to an exact single-CTA streaming radix (no co-residency, no cooperative barrier). ≥128 KB devices (Hopper, datacenter Blackwell) are byte-identical. This also addresses the intermittent long-context wedge reported on 4-node GB10 in the comments.

Fresh llama-benchy baselines (GB10 SM121, 2-node TP=2)

Pinned standard: fp8 KV, prefix-cache ON, FULL_AND_PIECEWISE, max-model-len 49152, util 0.85, max-num-seqs 64, max-num-batched-tokens 8192; llama-benchy 446dd42f (--pp 2048 --tg 128 --depth 8192 16384 32768 --concurrency 1 --runs 3 --enable-prefix-caching). Build 444fe3ac8b.

DeepSeek-V4-Flash + MTP2 (production config):

depth ctx_pp (t/s) pp2048 prefill (t/s) tg128 decode (t/s, peak)
8 192 1696 1293 38.2 (43.7)
16 384 1662 1224 41.4 (46.3)
32 768 1582 1124 41.3 (46.0)

DeepSeek-V4-Flash-DSpark (self-drafting block-5, V1 runner, same knobs):

depth ctx_pp (t/s) pp2048 prefill (t/s) tg128 decode (t/s, peak)
8 192 1602 1290 32.8 (38.6)
16 384 1594 1160 30.8 (41.3)
32 768 1582 1128 37.4 (49.0)

DSpark serves cleanly on GB10 2-node. On this Gutenberg book-continuation workload its draft acceptance is content-limited (~1.8–3.0 mean accept length over its 5 draft positions), so its decode is comparable to MTP2 here; DSpark's higher-acceptance advantage shows on more predictable / reasoning-style content (and the V2 speculator path accepts more still). Decode on multi-node GB10 is interconnect-latency-bound (GPUDirect RDMA is unavailable on GB10's unified memory → NCCL-over-TCP), which caps single-stream decode regardless of runner.

Running DSpark

DSpark is DeepSeek's self-drafting speculative-decode variant (draft weights carried in the target checkpoint, block size 5). Serve the DSpark checkpoint with the dspark speculative method:

vllm serve deepseek-ai/DeepSeek-V4-Flash-DSpark \
  --tokenizer-mode deepseek_v4 --trust-remote-code \
  --tensor-parallel-size 2 --kv-cache-dtype fp8 \
  --speculative_config '{"method":"dspark","num_speculative_tokens":5}'
  • num_speculative_tokens must be 5 (the checkpoint's dspark_block_size); the draft is self-hosted, so no separate --speculative-model is needed.
  • Runs on the V1 runner by default on this branch (correct long-context recall). Opt into the V2 DSpark speculator — which accepts more per step — with VLLM_USE_V2_MODEL_RUNNER=1; V2's long-context recall is now correct after the Add CUDA graph-based all reduce launcher #26 padded-Q fix.
  • The DSpark FP8 checkpoint is ~2× the size of the default W4A16 DeepSeek-V4-Flash, so it leaves less room for KV cache; size --gpu-memory-utilization / --max-model-len accordingly.

Update 2026-06-26 — synced onto upstream/main + dual-arch revalidation

Re-synced the branch onto current upstream/main (merge c7a4386a45, then the C128A device-sync hoist → c766cbc6ff; 198 upstream commits since the #43477 merge). 6 conflicts resolved:

  • oracle/nvfp4.py — union the SwiGLU-clamp backend set to {TRTLLM, CUTLASS, MARLIN}. Our FLASHINFER_CUTLASS clamp fix landed upstream as [Bugfix] Allow flashinfer_cutlass as a clamped NVFP4 MoE backend #46492, so the fork patch is now redundant.
  • routed_experts.py — combine the two per-tensor-scale loaders into one helper (our e8m0 bitwise view and upstream's 0-D/shape-(1,) _to_scalar normalization).
  • serve/render/serving.py + renderers/online_renderer.py — upstream's [Frontend] Split ServingRender into renderer and entrypoint. #44285 split ServingRender into renderer + entrypoint; our DSv4 thinking→template-kwargs threading is re-homed onto the new structure (sampling-params site in ServingRender.render_chat_request, prompt-render site in OnlineRenderer.render_chat).
  • sparse_attn_indexer.py — preserve our SM120 short-row / persistent top-k path, and add upstream's new cooperative_topk ([Perf][DSv4/DSv3.2] Add cluster-cooperative topK kernel for low-latency scenarios #43008) gated to exclude capability family 120, so SM12x decode is byte-identical to the validated path (enabling cooperative_topk on SM12x is a separate, to-be-validated perf experiment).
  • engine/protocol.py (keep both DeltaMessage hooks) and tests/models/test_deepseek_v4_mega_moe.py (keep CompilationConfig() fixture).

Inherited for free from the sync: deepseek_v2 redundant-clone removal (#46651), sampler int32-overflow fix (#46560), spec-decode correctness (#45956 / #46533).

Validation — full matrix, both arches, on c766cbc6ff:

arch correctness perf
RTX SM120 (TP=2) GSM8K-200 0.97, #19 instruction-following PASS throughput 8000×1000 captured
GB10 SM121 (2-node TP=2) GSM8K-200 0.945, arthur coherence 64/64 + 24/24, Forum53 multi-user PASS llama-benchy ctx_pp +80% (1709/1667/1588 t/s @ d8192/16384/32768 vs the prior pinned baseline 943/928/883, reproduced across two runs), decode flat (ctx_tg 38.6/38.5/37.4)

The MoE backend on both arches is Marlin (W4A16)is_deep_gemm_supported() is False on stock SM12x wheels, so the DeepGEMM/W4A8 path isn't selected and Marlin is the default; it is GSM8K-correct on both SM120 and SM121. The prefill +80% is inherited from upstream's prefill / scheduler / block-pool work (not an SM12x change of ours); decode is flat because our SM12x decode path is preserved unchanged.

Update 2026-06-23 — #43477 reconciliation (stock-deps path) + dual-arch revalidation

Upstream merged #43477 (DeepSeek V4 + GLM-5.1 on SM120 via the FlashInfer-SM120 sparse-MLA route) on 2026-06-22. As merged it does not run on released wheels: its SM12x attention class raises at model construction unless the unreleased FlashInfer #3395 fork symbols are present, and it auto-enables a DeepGEMM MXFP4 path whose pinned ref (#324) asserts on SM120. This PR is now reconciled on top of it so the two coexist: #43477 is the fork-deps route; this PR is the stock-deps route that runs on released FlashInfer / DeepGEMM wheels.

Reconciliation is a merge of upstream/main into the PR branch (42657aca65, 6 conflicts resolved — kept our env/availability-gated SM120 decode route + both FlashInfer probes + #43477's gated prefill-SWA mechanism; dropped our now-redundant block_pool stale-hash reset in favour of upstream's), plus two fixes for regressions the merge introduced:

  1. DeepGEMM no longer auto-enabled on SM120 (a94657e601). Enable DeepSeek V4 and GLM-5.1 on SM120 #43477 added SM120 to support_deep_gemm, so the engine selected a DeepGEMM MXFP4 kernel whose pinned/released ref aborts at init (Assertion sf.size(-2)==ceil_div(mn,gran_mn)). SM120 now falls back to Marlin/cutlass as before (needs the unmerged DeepGEMM GPTBigCodeForCasualLM support doesn't work #324 to enable).
  2. Enable DeepSeek V4 and GLM-5.1 on SM120 #43477's prefill-SWA launch gated + kernel OOB clamped (f7b4b425b0). The merged paged prefill-SWA index kernel launched unconditionally and computed block_table addresses for masked-off tail lanes of deep (32k) prefill rows, which SM12x + Triton 3.6 faults as an illegal address even though the load is masked → cudaErrorLaunchFailure under concurrent load. The launch is now gated behind VLLM_DEEPSEEK_V4_FLASHINFER_SM120_PREFILL (default off → the stock decode-only path never launches it) and the kernel's masked lanes are clamped.

Validation — metrics flat vs the pre-reconcile head (5ba0f19f02), both arches, DeepSeek-V4-Flash, fp8 KV, MTP=2:

Check RTX SM120 (2× PRO 6000) GB10 SM121 (2-node)
Builds + serves on stock deps ✅ (2-node TP=2, NCCL 2.30.7)
GSM8K strict 0.9537528 — bit-identical to pre-reconcile 0.945 (5-shot, limit 200)
Default-path baseline phases identical exit codes to pre-reconcile
Prefill throughput (random 8192×512) 1638.4 tok/s — bit-identical
Long-context coherence (conc 2 / 12) (RTX 352/352 prior) 24/24
--async-scheduling A/B (off vs on), incl. 64k concurrency + sampling stress safe + equivalent, 0 crashes, 0 wedges

The earlier "--async-scheduling regression" note is withdrawn — that crash was this same prefill-SWA OOB, fixed above; with the fix, --async-scheduling on/off are equivalent and crash-free through a 64k concurrency-8 sampling soak.

Update 2026-06-23 — GB10 / DGX Spark (SM121) long-context frontier, 256k–1M

Cold-prefill capability sweep on 2× GB10 / DGX Spark (SM121), TP=2 over RoCE, max-model-len 1048576, gpu-memory-utilization 0.75, MTP=2, fp8 KV, EP-off, prefix-cache disabled, FULL_AND_PIECEWISE, greedy, C=1 (post-audit head). All five points complete cleanly (0 failures, no OOM / crash). KV cache = 1,868,754 tokens (5,964 bytes/token); a 1M-token request admits at 1.78× concurrency at this utilization.

Context Prompt tokens Cold TTFT
256k 261,588 392.6 s
384k 392,648 648.5 s
512k 523,728 963.7 s
768k 785,868 1769.0 s
1M 1,048,011 2789.0 s

The multi-minute cost is the cold prefill (TTFT), which is GPU-bound (GPU 96% throughout each TTFT window) and scales super-linearly (O(N^1.4): 256k→512k = 2.45×, 512k→1M = 2.89×).

A dedicated decode-vs-context sweep (256-token generation at 16k / 64k / 256k / 512k) shows the opposite for generation: steady-state decode is essentially flat with depth — median inter-step latency ~61–69 ms across 16k→512k (≈30–45 tok/s effective with MTP), i.e. throughput does not meaningfully degrade as context grows. That matches the per-step cost being dominated by fixed MoE GEMM + the 2-node RoCE all-reduce rather than the depth-dependent (O(N)) indexer. (Decode rates from a 16-token TTFT-only run are not reliable — too short, plus MTP bundling — so they are not used here.)

So the long-context penalty is concentrated entirely in the one-time cold prefill (TTFT above), not in generation: prefill is GPU-bound compute/bandwidth (LPDDR5X ~273 GB/s) plus the per-chunk 2-node RoCE all-reduce (no inter-node NVLink), while decode stays ~constant per token. MTP keeps ~2.0 acceptance at depth. Practically: GB10 suits large-context-in → generation-out when the one-time cold first token (minutes at 384k+) is acceptable or amortized by prefix caching; once generating, throughput is depth-independent. The 1M cold TTFT is ~20% faster than the 2026-06-06 baseline (2789 s vs 3504 s).

Update 2026-06-22 — long-context (256k+) crash fixes (latest validated head)

Two distinct crashes were reported at long context (≥256k, MTP=2). Both are fixed on sm120-pr-41834-stable-preview-20260622b (5ba0f19f02); the rest of the branch is unchanged from the 2026-06-21 audit head:

  1. Packed-prefill output slice (output.size(0)==num_tokens, 84 vs 83). With VLLM_DEEPSEEK_V4_FLASHINFER_SM120_PREFILL=1, the packed _forward_prefill path sliced the query to num_prefill_tokens under MTP/cudagraph padding but passed the unsliced padded output to the kernel, which derives num_tokens from q.shape and asserts output.size(0)==num_tokens → crash, cascading to an illegal-memory-access. The output is now sliced symmetrically. This path is gated (default FlashMLA prefill loops over q.shape and has no such assert → was never affected). Interim workaround for older builds: set VLLM_DEEPSEEK_V4_FLASHINFER_SM120_PREFILL=0.

  2. MTP draft-sampler float32 cast (engine-killing assertion on non-greedy sampling). The MTP probabilistic draft sampler fed bf16 draft-head logits into the Triton top-k/top-p kernel, which asserts logits.dtype==torch.float32AssertionError kills the worker (and cascades to CUDA errors on TP peers). This fires on any MTP + non-greedy (top-k/top-p/temperature) request and is independent of the FlashInfer gates — so it reproduces "even without FlashInfer". Greedy decoding returns before the sampler, which is why greedy GSM8K validation never surfaced it. The draft logits are now cast to float32 (matching the main sampler). Validated on 2x RTX PRO 6000 (SM120): a 256k sustained sampling soak (temperature 0.7 / top_p 0.9, 9 concurrent workers, MTP=2, EP) that crashed on the first sampled request now runs clean (200+ requests).

Note for very long contexts (e.g. --max-model-len 500000): the sparse-MLA / indexer workspaces are sized by max_model_len and are not yet SM12x-arch-gated, so they consume a large fixed share of VRAM and leave a thin KV budget — see #42856 for the workspace-shrink fix. This is a memory-headroom concern, separate from the two crashes above.

Update 2026-06-21 — post-audit cleanup

This supersedes the 2026-06-20 and 2026-06-18 heads and the earlier validation data below. An audit of the SM12x branch against current upstream removed redundant, disproven, and experimental deltas; the cleaned head is validated metrics-flat (marginally better) on 2x RTX PRO 6000 Blackwell (SM120) and 2-node GB10 / DGX Spark (SM121), DeepSeek-V4-Flash, fp8 KV, MTP=2. The jasl#19 (instruction-following) and #45309 breakable-cudagraph-garbage revert (#45972) correctness fixes are retained. Five changes:

  1. Breakable-cudagraph stays default OFF (FULL_AND_PIECEWISE). DeepSeek-V4 is deliberately excluded from breakable-cudagraph auto-enable — on real 2x GB10 MTP decode breakable regressed throughput and degraded as output length grew (≈31→19 tok/s at 400→800 max-tokens vs a flat ≈40). The gate is now a single MiniMax-only helper instead of a dead always-False stub; behavior is unchanged. (VLLM_USE_BREAKABLE_CUDAGRAPH=1 still opts in.)

  2. The long-context recall fix is the int64 block-offset cast, not a cache fence. The 2026-06-20 head attributed the MTP high-concurrency recall/garble bug to a missing copy-on-write on writable caches and added a prefix-cache write-completion fence. Further investigation showed that hypothesis was wrong: the actual cause is an int32 overflow of the packed-KV block offset in the SM12x paged-MQA-logits indexer kernels, fixed by an int64 cast (retained). With the int64 fix in place the write fence is redundant — a fence-OFF recall gate holds 8/8 @ conc=8 and 16/16 @ conc=16 (0 miss) on RTX, and 8/8 on GB10. The write-completion fence and the COW broadening paired with it were therefore removed.

  3. Removed the scheduler prefill-fairness heuristics (ungated, generic very-long-prefill / mixed-decode chunk-limiting). They targeted a decode cliff later re-diagnosed as MoE-GEMM + NCCL-all-reduce bound (not schedulable) and were not load-bearing: a cleanup-vs-prior A/B shows an identical mixed prefill/decode fairness ratio (0.716 vs 0.714) and equal inter-chunk latency.

  4. Moved the experimental VLLM_NVFP4_GEMM_BACKEND b12x research lever out of the PR (off-by-default, unused on the shipped path) and dropped a tool-calling-env diff-reflow churn.

Net vs the 2026-06-20 head: 6 files, −833 lines (the removed fence + scheduler heuristics + their tests). The decode/prefill CUDA kernels are byte-identical across the cleanup, so the gated-decode-optimization profile and the 2026-06-12 throughput baselines below are unchanged.

Validation, 2026-06-21

Trivial-prompt generation (cudagraph sanity), both platforms: 2+2 → 4, 7*8 → 56, capital of France → Paris — no garbage.

Default decode path, MTP=2:

Gate RTX SM120 GB10 SM121
GSM8K strict (8-shot full · 5-shot limit-200 · limit-100) 0.954 (full) · 0.96 (l200) 0.96 (l100)
Long-context recall, fence OFF, conc 8 / 16, MTP2 8/8 + 16/16, 0 miss 8/8
Instruction-following (jasl#19) pass (JSON-only)
tool-call (15-case suite) 87%
Scheduler-removal A/B — mixed prefill/decode fairness ratio (cleanup vs prior) 0.716 vs 0.714
random 8192×512 TPOT (cleanup vs prior, ms) 6.27 vs 6.5
indexed-D512 min-token gate 4096 vs 8192 — prefill @4k 9,687 vs 6,203 tok/s

The GB10 SM121 run is a from-scratch 2-node rebuild of the cleaned head (NCCL 2.30.7 re-pinned per node); arithmetic, GSM8K, and the long-context recall gate all pass, confirming the fence removal holds recall on SM121 as well. The recall fix is the int64 cast in the SM12x indexer kernel, so the 2026-06-12 throughput baselines below are unchanged.

llama-benchy (eugr format), GB10 2-node / SM121, MTP=2, prefix-cache on (GB10 MTP decode/prefill profile — unchanged across the 2026-06-21 cleanup, decode kernel byte-identical):

test t/s peak t/s ttfr (ms)
pp2048 (cold) 1205.5 ± 22 1705
tg128 (C=1) 40.0 ± 0.4 45.7
ctx_pp @ d8192 1722.5 ± 5 4762
ctx_tg @ d8192 38.5 ± 1.5 43.3
ctx_pp @ d16384 1674.8 ± 2 9788
ctx_tg @ d16384 39.2 ± 2.3 44.3
ctx_pp @ d32768 1595.3 ± 1 20547
ctx_tg @ d32768 41.6 ± 1.5 46.3

Prefill 1595–1722 tok/s at depth; decode 40 tok/s @ C=1 holding 38–42 out to 32K context (no decode cliff); prefix-cache hit 42–46% under MTP.

Gated SM120 decode optimization (VLLM_DEEPSEEK_V4_FLASHINFER_SM120_DECODE=1)

The decode gate uses flashinfer.mla._sparse_mla_sm120 (in FlashInfer main / 0.6.13; absent from the 0.6.12 release). Installing it correctly matters: a bare pip install --upgrade flashinfer-python @ git+main bumps flashinfer-python but leaves a stale flashinfer-cubin / flashinfer-jit-cache, and FlashInfer then raises a version-mismatch error at startup (and re-JITs kernels). Uninstall the precompiled packages first, then upgrade:

pip uninstall -y flashinfer-jit-cache flashinfer-cubin
pip install --upgrade "flashinfer-python @ git+https://github.com/flashinfer-ai/flashinfer.git"

For a reproducible pin instead of tracking moving main, install matching flashinfer-python + flashinfer-cubin nightlies (e.g. 0.6.13.dev20260619, a bit-identical decode kernel to the validated build) — again uninstalling flashinfer-jit-cache first.

RTX SM120, decode gate ON vs OFF, ctx0 decode (aggregate tok/s, 0 errors all rows):

C gate OFF gate ON gain
1 189.7 201.4 +6%
2 311.3 334.7 +8%
4 483.1 531.6 +10%
8 707.6 801.9 +13%
16 990.5 1164.7 +18%
32 1545.0 1849.6 +20%
64 2132.5 2814.8 +32%

gate-ON @C64 = 2814.8 tok/s matches the community target (~2815). The decode CUDA kernel is byte-identical across the rebase, so this profile is unchanged.

The default path needs no FlashInfer update. With the gate off (default), the import is lazy/gated, so FlashInfer 0.6.12 (official) works unchanged. On GB10 / 2-node, also pin nvidia-nccl-cu13==2.30.7 (a rebuild reverts it; a per-node mismatch hangs the NCCL handshake).

Gated SM120 prefill optimization (VLLM_DEEPSEEK_V4_FLASHINFER_SM120_PREFILL=1)

Symmetric to the decode gate, prefill has an opt-in packed FlashInfer sparse-MLA path: VLLM_DEEPSEEK_V4_FLASHINFER_SM120_PREFILL=1 (default off; ~+5–6% single-stream prefill). With it off, prefill defers to the default FlashMLA indexed-D512 path. It routes through the same flashinfer.mla._sparse_mla_sm120 kernels as the decode gate, so it carries the identical FlashInfer version requirement — the install/pin steps above apply unchanged (FlashInfer main / 0.6.13; the default-off path needs no FlashInfer update and runs on 0.6.12). Decode and prefill share one FlashInfer build; there is no separate version to track for prefill.

Branch validation, 2026-06-12

Base and head:

  • upstream base: 8a91228dbe363d1d113deb2a82e289429130dd01
  • PR head: f32247a5a695fa8979d61837bf6b87da897dcb7d
  • branch range: 96 commits over upstream/main

Commands run on the final head:

Command Result
git diff --check upstream/main...HEAD pass
DCO scan over upstream/main..HEAD pass; every commit has Signed-off-by
VLLM_TARGET_DEVICE=empty .venv/bin/python -m compileall -q vllm/envs.py vllm/model_executor/warmup/kernel_warmup.py vllm/models/deepseek_v4 vllm/v1/core vllm/v1/attention/backends/mla vllm/reasoning/deepseek_v4_reasoning_parser.py tests/test_envs.py tests/v1/core/test_prefix_caching.py tests/v1/core/test_scheduler.py tests/reasoning/test_deepseekv4_reasoning_parser.py tests/quantization/test_sm12x_tuned_config_lookup.py pass
.venv/bin/python -m pytest tests/test_envs.py::test_deepseek_v4_sparse_mla_stats_path_env -q on the remote vLLM environment 1 passed, 16 warnings
python3 -m pytest tests/test_scripts.py -q in the public harness 128 passed in 14.41s

Local vLLM pytest/ruff were not run on the Mac checkout because its .venv does not currently include torch or ruff. GPU-path validation remains remote SM120/SM121-only.

Latest clean SM120 RTX PRO 6000 x2 data, 2026-06-12

Artifact roots:

  • artifacts/codex_pr_stable_preview_f32247a/2x_rtx_pro_6000_sm120/rtx_current_pr_short_throughput_mtp_noep_20260612084721
  • artifacts/codex_pr_stable_preview_f32247a/2x_rtx_pro_6000_sm120/rtx_current_pr_clean_mtp_noep_20260612080629

Short-throughput profile:

  • TP=2, MTP=2, expert parallel off, FP8 KV, block size 256.
  • max_model_len=131072, gpu_memory_utilization=0.975, max_num_batched_tokens=4096, max_num_seqs=24.
  • Prefix cache disabled, FULL_AND_PIECEWISE, 80 prompts per concurrency.
  • Phase exits: server_startup=0, bench_hf_mt_bench=0, bench_random_prefill_sweep=0.
  • Regression check: output/input throughput ratios are against the previous accepted same-profile EP-off reference; all are above the 0.95 floor.

HF MT-bench, 80 prompts:

C output tok/s ratio vs reference mean TTFT ms p99 ITL ms MTP acceptance %
1 180.94 1.009 49.59 13.08 68.36
2 284.53 1.003 70.04 32.35 68.19
4 427.10 0.999 82.70 38.83 68.25
8 600.33 1.005 110.97 86.19 67.91
16 840.46 1.019 156.73 86.50 67.34
24 987.77 1.030 209.05 86.71 68.20

Random prefill sweep, C=1, output length 128, 8 requests per case:

Prompt / output tokens input tok/s ratio vs reference mean TTFT ms requests
4K / 128 3123.74 0.996 660.21 8 / 8
16K / 128 6209.00 1.005 2030.49 8 / 8
64K / 128 7049.72 0.999 8715.51 8 / 8

Correctness and reliability profile:

  • TP=2, MTP=2, expert parallel off, FP8 KV, prefix cache disabled, max_model_len=131072, max_num_seqs=4, max_num_batched_tokens=4096.
  • Phase exits: server_startup=0, bench_hf_mt_bench=0, eval_gsm8k=0, bench_random_prefill_sweep=0, bench_random_8000x1000=0, bench_random_256x256=0.
  • Post-run current-boot driver scan found no Xid, UVM, NV_ERR, GPU-lost, illegal-access, unspecified-launch, or fatal GPU signals; no vLLM compute processes were left running.

GSM8K 5-shot, limit-200, /v1/completions, MTP=2, concurrency 4:

Metric Value Floor Result
flexible exact match 0.965 0.940 pass
strict exact match 0.940 0.925 pass

Additional 128K-profile random checks:

Shape C output tok/s mean TTFT ms p99 ITL ms MTP acceptance %
8K / 1K 1 130.93 1367.03 13.44 52.56
8K / 1K 2 191.19 1586.64 17.44 50.28
8K / 1K 4 260.72 1666.96 199.75 51.76
256 / 256 1 153.07 88.80 13.17 51.46
256 / 256 4 369.86 127.80 84.44 52.50

Latest clean GB10 / SM121 data, 2026-06-12

Artifact root:

  • artifacts/codex_pr_stable_preview_f32247a/2x_gb10_sm121/gb10_forum53_mtp2_epoff_c2_gmem0685_mml81920/20260612074113

Profile:

  • TP=2, MTP=2, expert parallel off, FP8 KV, block size 256.
  • max_model_len=81920, max_num_seqs=2, max_num_batched_tokens=4096, gpu_memory_utilization=0.685.
  • Prefix cache enabled; Forum Refactor attention kernels #53 C=2 shape: forum53_c2:2:2:3200:256.
  • This covers the 80K-token prompt case on the final PR head. Failed, interrupted, or driver-signal artifacts are intentionally excluded from this PR body.

Gate result:

Gate Result
summary ok true
serve_start.exit_code 0
streaming_pressure.exit_code 0
driver health ok=true, signal count 0
request failures 0 / 4
preemptions 0

Timing and runtime summary:

Metric Value
max prompt tokens 80,127
max TTFT 124.045698 s
max elapsed 124.949141 s
avg inter-chunk latency 0.056711 s
p95 inter-chunk latency 0.064278 s
p99 inter-chunk latency 0.144954 s
max inter-chunk latency 0.144954 s
GPU KV usage avg / max 65.81% / 86.40%
prefix-cache hits / queries 79,872 / 3,444,165

Running the NVFP4 checkpoint

This branch also serves nvidia/DeepSeek-V4-Flash-NVFP4 on SM12x (RTX PRO 6000 / GB10). The NVFP4 MoE auto-selects the FlashInfer CUTLASS backend (the SwiGLU-clamp model gate now accepts it), so no --moe-backend flag is required, and no special FlashInfer build is needed (the 0.6.12 release works):

vllm serve nvidia/DeepSeek-V4-Flash-NVFP4 \
  --trust-remote-code --tensor-parallel-size 2 \
  --kv-cache-dtype fp8 \
  --tokenizer-mode deepseek_v4

--kv-cache-dtype fp8 is mandatory: DeepSeek-V4's fp8_ds_mla attention asserts an fp8 KV layout, so the default auto fails at model construction (this is not NVFP4-specific). Expert-parallel off (plain TP) is the supported path.

Accuracy matches MXFP4 (GSM8K 8-shot ~0.96 on both SM120 and SM121). Note that on SM12x NVFP4 is not a memory or throughput win versus MXFP4: NVFP4 weights are ~4 GiB/GPU larger (~78 vs ~74 GiB), leaving less KV-cache room (lower max concurrency); single-stream prefill is marginally faster and aggregate decode marginally slower. Its value here is checkpoint availability / parity with the SM100 datacenter path, not an SM12x performance advantage — MXFP4 remains the better practical choice on consumer Blackwell.

AI assistance disclosure

AI assistants, including OpenAI Codex/GPT models and Anthropic Claude models, were used for code review, refactoring support, regression-script writing, and benchmark analysis. The branch was validated through human review plus the commands and harness artifacts listed above.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@mergify mergify Bot added deepseek Related to DeepSeek models nvidia v1 labels May 6, 2026
@jasl

jasl commented May 6, 2026

Copy link
Copy Markdown
Contributor Author

@zyongye
I've cleaned up the old PR, could you help review this one?

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements support for DeepSeek V4 on SM12x (Blackwell) architectures by providing Triton-based fallbacks for DeepGEMM-dependent operations. Key enhancements include the introduction of specialized Triton kernels for sparse MLA, FP8 einsum, and MQA logits, as well as memory optimizations in the sparse attention indexer to compute top-k indices without materializing full logits. Additionally, the PR updates the model loader to support weight name filtering for skipping MTP weights and handles Blackwell-specific FP8 quantization scales. I have no feedback to provide.

@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

def _sparse_indexer_requires_deep_gemm() -> bool:
return current_platform.is_cuda() and not (
current_platform.is_device_capability_family(120)
)

P1 Badge Keep DeepGEMM requirement for SM120 FP4 indexer path

This helper now disables the DeepGEMM requirement for every SM120 run, but the FP4 indexer cache path still depends on DeepGEMM kernels (fp8_fp4_*) because the new SM120 fallback only handles q_scale is None (FP8 Q). With use_fp4_cache=True on SM120 and no DeepGEMM installed, construction succeeds and the first prefill/decode call fails at runtime with the DeepGEMM _missing() error instead of being rejected up front.


if self.load_config.load_format == "fastsafetensors":
weights_iterator = fastsafetensors_weights_iterator(
hf_weights_files,
self.load_config.use_tqdm_on_load,
)

P2 Badge Propagate weight_name_filter to fast safetensor loaders

The new pre-load weight_name_filter is only wired into safetensors_weights_iterator; this branch still loads all tensors for fastsafetensors (and similarly other non-default safetensor iterators), so skipped tensors are still materialized. For DeepSeek V4 this defeats the intended early skip of MTP weights and can reintroduce high transient memory use/OOM when these load formats are enabled.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@jasl jasl changed the title [New Model][Nvidia] Add SM12x support for DeepSeek V4 Flash [New Model][Nvidia] Add SM12x support for DeepSeek V4 Flash with essential fixes May 6, 2026
@jasl jasl force-pushed the codex/ds4-sm120-min-enable branch from 042e366 to df2e6f8 Compare May 6, 2026 16:26
jasl added 2 commits July 3, 2026 22:19
…ore einsum alignment hint

Two fixes for the DeepSeek-V4 sm12x Triton kernels, reported by GanyX19
(vllm-project#41834) from running this branch on GB10/sm_121 under load.

1) sm12x_mqa.py: the three MQA-logits kernels declared per-request-varying
   values (num_q/seq_len_kv, num_rows/logits_width, stride_lm) as tl.constexpr.
   Triton caches a distinct kernel per distinct value for the process lifetime;
   on GB10 the compile cache is unified memory, so it grows unbounded (~5-6 GB/h/
   node) until the host hard-freezes. These are used only in boundary masks and
   address arithmetic (verified: never a tl.arange/range extent), so make them
   plain runtime args -> Triton specializes only on divisibility. stride_ln (=1)
   and all tile/shape constexprs stay.

2) fp8_einsum.py: this branch already keeps num_tokens/a_stride_group/
   a_scale_stride_group runtime (to avoid the same leak) but without a
   tl.multiple_of hint, which drops them to divisibility=1 and costs ~24%
   single-stream decode. Both are provably div-by-16 (a_stride_group = T*hidden,
   hidden%128==0; a_scale_stride_group via align(T,4)*32), so hint them while
   keeping them runtime. Applied to both the BF16 and the fused-quant einsum
   kernels (same matmul, same strides). a_scale_stride_hidden / tf32 strides are
   NOT hinted (not div-by-16-provable).

Co-authored-by: GanyX19
GanyX19 finding #3: _tf32_hc_prenorm_gemm_kernel has the same per-shape
constexpr-recompile pattern (stride_outs/stride_sqs vary with M). Same
constexpr->runtime treatment (address-arithmetic only, verified no arange/range
extent); no multiple_of hint (store path, not div-by-16-provable). Minor
contributor, material mainly at 1M context.

Co-authored-by: GanyX19
@jasl

jasl commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

@GanyX19 — both reproduced and verified on our GB10 (sm_121, 2-node TP=2), and all three are now landed on this branch (codex/ds4-sm120-min-enable, commits 15434cb64f + b43470e871, co-authored to you). Excellent, careful report — thank you.

#1 — MQA-logits constexpr leak. Confirmed: the three kernels declare num_q/seq_len_kv, num_rows/logits_width, stride_lm as tl.constexpr (9 lines), and grepping their uses they only ever appear in boundary masks (offs < num_rows) and address arithmetic (* stride_lm) — never a tl.arange/range extent — so making them plain runtime args is safe, exactly as you said. stride_ln (=1) and the tile/shape constexprs stay. This matches host-freeze behaviour we'd independently hit under sustained load on unified memory.

A nice corroboration fell out of our A/B: in a controlled run the pre-fix (constexpr) build logged _tf32_hc_prenorm_gemm_kernel JIT-compiling during inference and then died with EngineDeadError under the benchmark — i.e. the recompile pathology biting live. The fixed build ran the same load clean.

#3 — tf32 prenorm gemm. Took this too: same constexpr → runtime for stride_outs/stride_sqs (address-arithmetic only, no hint — store path, as you noted).

#2 — einsum alignment hint. Confirmed our branch already keeps a_stride_group/a_scale_stride_group runtime (per the do-not-specialize comment) but without a tl.multiple_of. Added the hint — and applied it to both the BF16 einsum kernel and its fused-quant sibling _deepseek_v4_sm12x_fp8_einsum_quant_kernel (same matmul, same A strides). Left a_scale_stride_hidden and the tf32 store strides un-hinted, per your reasoning about ÷16-provability.

Validation (GB10, DSv4-Flash MTP-2): correctness clean with the hints in — GSM8K-100 = 0.99, needle-recall 16/16 — so the multiple_of doesn't corrupt on our shapes. On decode the change is throughput-neutral at our benchmark context sizes (≤32K), and we didn't observe the −24%/recovery there. That lines up with your bisection being at 256K single-stream where the einsum is a much larger fraction of the step — it should recover exactly as you measured at long context; we just don't exercise that regime in the standard bench. The host-memory-leak → freeze fix is the headline win for us, and it's decode-neutral, so it's a clean take regardless.

Really appreciate the depth here — the mask/arithmetic-vs-extent distinction and the ÷16 safety analysis made these trivial to verify. Glad to have you running the branch on GB10.

@mergify

mergify Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @jasl.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@wingcomm

wingcomm commented Jul 4, 2026

Copy link
Copy Markdown

Both fixes confirmed in production on our 4-node TP=4/EP=4 GB10 rig — thank you. And one remaining rough edge (long-context prefill latency) I'd love your read on.

9bccfd1 (persistent_topk) — wedge resolved. We've now run 11h+ at max_model_len=1048576 (full 1M) with zero wedges. The 1M-width cudagraph warmup — the exact spot that hard-failed on the oversubscribe
pre-fix — captures clean, 0 oversubscribe. And a pleasant surprise: on the current build the C128A workspace self-sizing shrank the per-context sparse-MLA workspaces enough that our KV pool is larger at 1M
(4.63M tokens) than it was at 512K (2.91M) — so we got the full context window back with more concurrency headroom, not less.

15434cb (MQA-logits constexpr leak) — the memory-creep headline win, confirmed. GanyX19's diagnosis was exactly our long-standing "unexplained ~2.4–3.5 MB/req creep to freeze." Post-fix, measured two ways:
the Triton compile cache now grows ~1.9 MB/hour (vs the pre-fix ~5–6 GB/hour — a ~3000× cut), and host avail holds flat (~16.8 GB across 8h) instead of marching to the earlyoom floor. The ~17h-restart
treadmill is over. (b43470e's tf32-prenorm runtime strides + einsum multiple_of are in too.)

The rough edge — long-context prefill monopolizes the engine (~1–2 min). Now that 1M is enabled, a single large prefill stalls everything. A representative window (2 requests running, ~1M-scale context):
prompt 0 tok/s, gen ~0 tok/s, Running 2, Waiting 0→2→4 (~1m50s)
prompt 11,609 tok/s, gen 51, Running 3, Waiting 1 (prefill lands, recovers)
For ~110s the engine emits ~0 tokens while grinding the prefill, the queue backs up to 4 waiting, then it completes in a burst and recovers. Not a wedge — clean recovery — but a real latency/throughput hit
under concurrency. Our read is it's the streaming-radix histogram_streaming_topk path (the re-stream-from-global-each-round you flagged) dominating the step at large context, compounded by decode being
interconnect-latency-bound here (NCCL-spin-wait, so we can't hide it behind other work). We tried chunked-prefill interleaving (smaller max_num_batched_tokens) earlier and it barely helped — even a 26K
prefill blocked a small request ~10s; the chunks are too heavy to interleave on this fabric.

Questions for you:

  1. Is the streaming-radix long-context cost something you expect to optimize (e.g. a single-pass or cached-prefix variant), or is re-streaming inherent to fitting GB10's ~99 KB smem?
  2. Any scheduling lever you'd recommend to bound the monopolization — long_prefill_token_threshold, max_num_partial_prefills, or something else — that actually interleaves on this hardware?
  3. Given the einsum multiple_of hint recovers ~24% at long context, is more of the prefill step similarly recoverable, or is the top-k the hard floor?

Happy to run a controlled prefill-latency-vs-context sweep on the 4-node rig if that'd help you characterize it — we're a real 1M/EP=4 production case.

@jasl

jasl commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

@wingcomm — this is fantastic to hear, thank you for the production confirmation. 11h+ at full 1M with zero wedges, the 1M cudagraph warmup capturing clean, and the ~5–6 GB/h → ~1.9 MB/h memory-creep cut (~3000×, "the 17h-restart treadmill is over") are exactly the outcomes we were hoping for — and the KV-pool bonus at 1M (4.63M vs 2.91M @512k from the C128A workspace self-sizing) is a nice surprise. Really appreciate you closing the loop with hard numbers on a real EP=4 rig.

On the long-context prefill monopolization — good diagnosis, and your read is mostly right. Answers:

1. Is the streaming-radix cost inherent to 99 KB smem? — No, it's a conservative floor, and there's real headroom.
The histogram_streaming_topk path re-streams the row from global on each of its 4 MSD-radix rounds — that's ~4× full-row reads at 1M, which is the cost you're seeing. We shipped it that way for exactness under the 99 KB cap (the bounded-buffer histogram_256 path under-selects at dense pivots — >4096 ties in a 256-bin threshold bin — which is why it's gated to ≤32 KB-context). But it doesn't have to re-read the whole row every round:

  • After round 1 you already know the top 8-bit bin; rounds 2–4 only need the elements in that bin (~N/256), which for 1M is ~4096 — small enough to buffer in smem. So a "narrow-after-round-1" variant is ~1 full read + 3 passes over a few-K buffer, not 4 full reads.
  • Even simpler: our own histogram_2048 (2048-bin, single-pass + bounded buffer) fits 99 KB and at 1M has only ~488 elements per threshold bin — well under the 4096 buffer, so it wouldn't overflow. It's currently capped at HIST2048_THRESHOLD=8192 conservatively; extending a finer single-pass histogram to the long-context force_noncooperative path (with the 4-round re-stream kept only as the rare genuinely-dense fallback) should recover most of the cost.

So: the re-stream is exact-and-safe, not minimal. I think it's optimizable and I'd like to prototype the finer-histogram/narrow variant — your sweep offer is exactly what would guide it (see below).

2. Scheduling lever — the reason chunked-prefill barely helped is structural, and it bounds the fix.
The indexer top-k is over the accumulated context, not the query chunk. So splitting a 1M prefill into chunks doesn't split the top-k work — each late-context chunk still runs a ~full-context top-k (the ~seconds you measured), it just reschedules it. Smaller max_num_batched_tokens interleaves finer but every late chunk is still heavy, so you hit a floor. The levers that do help are the partial-prefill ones, not the batched-token size: set max_num_partial_prefills > 1 and a long_prefill_token_threshold below your prompt size — that classifies the 1M prefill as "long" and lets short prompts jump ahead between its chunks (max_long_partial_prefills caps how many long ones run concurrently). That bounds the starvation (your Waiting 0→4 backing up) even though it can't shrink the long prefill's own cost. It's a latency-for-small-requests win, not a throughput win — the throughput fix is making the top-k cheaper (#1).

3. Is more of the prefill recoverable like the einsum? — No, different kind of floor.
The einsum win was a Triton alignment recovery (tl.multiple_of) — narrowed loads, free to restore. The top-k cost is algorithmic/bandwidth (the re-stream), so an alignment hint won't touch it; the recoverable part there is #1 (finer histogram). It's worth auditing the other long-context prefill kernels (sparse-MLA prefill, the MoE GEMMs) for their own alignment/warmup recoveries, but the top-k is the algorithmic hard part at 1M — and, as you note, decode being NCCL-spin-wait-bound on this fabric means you can't hide the prefill behind other work, which compounds it independently of the kernel.

On the sweep — yes please. A controlled prefill-latency-vs-context sweep on your 4-node 1M/EP=4 rig would be genuinely useful: it'd confirm whether the top-k actually dominates the step (vs the sparse-MLA prefill / MoE) at 1M, and give me a target curve to validate a finer-histogram prototype against. If you can capture per-context-length step time (say 128K / 256K / 512K / 1M, single large prefill, idle otherwise) plus a py-spy/nsys sample of where the ~110s goes, that pins it. If the top-k is the bulk, I'll take a run at the finer-histogram variant and send you a patch to try. We don't have a 4-node/1M setup here (our GB10 validation is 2-node), so your rig is the real characterization platform.

…e reads)

The force_noncooperative long-context path (seq_len > 32768 on <128KB-smem parts
like GB10) used histogram_streaming_topk: 4 full-row re-reads (one per MSD-radix
round). Replace with a 2-pass variant of histogram_2048_topk (CacheBins=false):
Phase 1 builds the 2048-bin histogram, Phase 2 re-reads from global (instead of
the per-thread register cache that caps the 1-pass path at ~8K) and collects,
Phase 3 exactly refines the buffered tie bin. 2 full-row reads vs 4. The finer
2048 bins keep the tie bin small (~seq/2048 << the 3708 buffer at 1M). Exact;
falls back to the streaming radix if the fp16 tie bin overflows the buffer
(never on GB10's ~1.9M max). Targets wingcomm's 1M-prefill top-k monopolization
(PR#41834). Prototype — pending standalone bit-exact + perf validation.
@jasl

jasl commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

@wingcomm — didn't wait for the sweep: took a run at the finer-histogram top-k and it's landed on this branch (codex/ds4-sm120-min-enable, 9f18be7630), ready for you to try.

What it is. The 4-round streaming radix re-read the full row 4× (once per MSD-radix round). The new path (histogram_2048_topk<TopK, CacheBins=false> in persistent_topk.cuh) does the finer-histogram approach we discussed: Phase 1 builds the 2048-bin histogram, Phase 2 re-reads once and collects, Phase 3 exactly refines the buffered pivot bin. 2 full-row reads instead of 4. The finer bins keep the pivot bin small (~seq/2048 ≈ 488 at 1M, well under the 3708 tie buffer), so it stays single-CTA / 99 KB-smem-safe. If the pivot bin does overflow (fp16 bins clustering — e.g. thousands of exactly-equal logits), it falls back to the streaming radix, so it's provably exact for any distribution.

Validated (GB10 2-node here):

  • Bit-exact vs torch.topk across 64K–1M × {spread, dense-pivot, >4096-exact-ties} — value multiset identical, no dups, fallback verified.
  • Kernel time (single row): 64K 0.038 vs 0.059 ms, 256K 0.078 vs 0.159, 512K 0.128 vs 0.291, 1M 0.229 vs 0.557 ms — 2.43×. The win grows with context (it's read-bound, so halving reads pays off more the longer the row). Only the rare overflow/dense-pivot case is ~12% slower (5 reads: the histogram pass + the streaming fallback).
  • Serve: DSv4-Flash at max_model_len=524288, arthur needle-recall at ~434K = 2/2, clean, no oversubscribe/errors — the 2-pass path is correct in the real indexer.

The honest caveat, and where your sweep comes in. This is 2.4× on the top-k. Whether it moves your ~110 s prefill depends on how much of that step the top-k actually is — which is exactly what your prefill-latency-vs-context sweep + a py-spy/nsys sample would show. If the top-k dominates, you should see a real cut; if the sparse-MLA prefill / MoE dominate, less so (and those are the next targets). Would love the before/after on your 4-node/1M rig — pull 9f18be7630 (or I can cut a tag) and re-run the representative window. If it helps end-to-end I'll tag it + write it into the PR notes.

forward_mqa passed seq_lens=None to the trtllm-gen sparse decode, so the
kernel walked all topk_tokens block-table entries and masked the -1 padding
that appears whenever a request's context < topk_tokens. Every sibling
sparse-MLA backend (flashinfer_mla_sparse, flashattn_mla_sparse,
flashmla_sparse) passes the per-token valid count instead, and upstream
PR vllm-project#47527 proposes the same for this SM120 path.

triton_convert_req_index_to_global_index already computes that valid count in
the same kernel pass at no extra cost; request it (return_valid_counts=True)
and forward it as seq_lens so the kernel reads only each request's valid
prefix. Bit-identical to seq_lens=None on the long-context path (context >=
topk_tokens => valid_count == topk_tokens); at short context it skips the
padding tail instead of masking it. Primary value is convergence with the
sibling backends and vllm-project#47527; perf-neutral on GB10 (interconnect-bound decode,
long-context benchmarks carry no padding).

Validated on GB10 2-node TP=2 MTP2 (mml 524288): arthur 434k conc1 2/2
(long, no padding), GSM8K 5-shot/200 = 0.96 (short, padding present -> new
prefix-skip path), serve-side error scan clean.
@wingcomm

wingcomm commented Jul 5, 2026

Copy link
Copy Markdown

Problem definition: long-context prefill is slow on GB10 (4-node TP=4/EP=4, 1M ctx)

The root: a single large prefill is minutes long — no concurrency involved

One request, ~536K prompt tokens, max_tokens=1, engine otherwise light: prefill (TTFT) = 573 s (~9.5 min). ≈930 tok/s prefill. That's a single request — no parallelism, no scheduler contention — so it isolates the raw kernel cost cleanly. It lines up with your own 2-node number (535K needle = 513 s), so it reproduces across topology.

This is the problem. Everything else is a consequence of it.

Where the ~9.5 min goes

py-spy sampling the worker's MainThread across the prefill (8 samples):

component share
sparse-MLA attention (flashmla / attention_impl) ~50%
MoE (fused_moe) ~37%
indexer / top-k (sparse_attn_indexer) ~12%

So your histogram_2048 top-k win (2.43× on ~12% of the step) is a real ~7–15% per-prefill cut — but the sparse-MLA attention (~50%) and MoE (~37%) dominate long-context prefill, and look like the higher-leverage targets. The step is also interconnect-latency-bound here (GPUs spin-wait on NCCL over the CX7 fabric), so launch-overhead reductions mostly don't help — it's genuinely kernel/comms time.

Profile caveat: this is py-spy (samples where the Python thread sits — often parked in a cuLaunchKernel), a proxy for GPU time, not a kernel timeline. nsys isn't in the serving container and host-attach needs sudo we don't have on these nodes — so if a real nsys/ncu GPU-kernel breakdown would sharpen the MLA-vs-MoE split, we'll relaunch the serve under nsys in a maintenance window and capture a 1M-prefill trace. Say the word.

Why it surfaces as a "stall with parallel requests"

The several-minute single-prefill is the cost the submitter of a big request pays. It becomes a stall for everyone else under concurrency because max_num_partial_prefills=1 (default) → the engine prefills one request at a time, so N concurrent large requests serialize:

07:31:55  prompt 0.0 tok/s  gen 0.0  Running 2  Waiting 0   <- one prefill monopolizes
07:33:15  prompt 0.0 tok/s  gen 0.3  Running 2  Waiting 4   <- queue backs up ~110s
07:33:55  prompt 11609 tok/s gen 51  Running 3  Waiting 1   <- prefill lands, recovers

So parallel-stall ≈ N × single-prefill-time. Chunked prefill doesn't hide it — the indexer top-k is over the accumulated context, so every late chunk still does ~full-context work.

Fix space

  • Kernel (yours, the root): MLA-attention + MoE dominate the ~9.5-min prefill — bigger targets than the top-k for long-context latency. Speeding these helps single-request TTFT and the parallel stall equally.
  • Scheduler (ours): max_num_partial_prefills gates the serialization; raising it might overlap prefills, but with the accumulated-context top-k + interconnect-bound step our guess is it'd thrash — an open question we'd value your read on.

@jasl

jasl commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

This is excellent isolation work — thank you. It matches our read, and it correctly reframes where the top-k sits.

Agreed on the shape. top-k ≈12%, and your ~7–15%-per-prefill math for the 2-pass histogram is right — it was always aimed at the top-k monopolization (the 4-read streaming blowup when a ~1M-context row overflows the register cache), never the prefill floor. MLA + MoE are the floor, and it's interconnect-bound — that lines up with our own 2-node decode teardown (MoE-GEMM + NCCL-AR + dense-GEMM as the top-3), just shifted toward attention on the prefill side.

One caveat before we chase the 50/37 split — py-spy will over-attribute to whichever kernel the NCCL-wait parks in. On an interconnect-bound step the MainThread sits in cuLaunchKernel behind the collective, so a sample lands on whatever kernel is launching when the stall hits — which inflates the two biggest launchers (MLA + MoE) with comms-wait time. Your own caveat, and it's the load-bearing one: if a meaningful slice of the wall-clock is actually NCCL-wait overlapped onto those launches, the true GPU-kernel split — and therefore the target — moves. So yes, please grab the nsys trace. A single-request 536K (or 1M) prefill, max_tokens=1, GPU-kernel timeline is exactly what decides MLA-attn vs MoE-GEMM vs indexer vs comms. That's the gating measurement before either of us commits kernel effort — say the word and we'll take whatever you capture.

One thing already pointing that way: your 4-node 573s is actually a hair slower than the 2-node 513s for the same ~535K — i.e. adding two nodes bought more collective latency than compute relief. For a single fat prefill that's a fingerprint of the comms-bound regime (the fabric, not FLOPs, is the near-term wall), and it says the node count that minimizes single-prefill TTFT may be the smallest one that still holds the KV — the extra nodes are there for 1M capacity, not speed.

One structural note that the trace should confirm: the ~50% "sparse-MLA attention" in prefill is DSv4's MHA-style prefill path — a different kernel from the sparse-decode one the top-k / FI-SM120 work touches (the SM120 sparse backend is decode-only). So it's separately optimizable and, as you say, the higher-leverage target. On the MoE side (~37%), consumer-Blackwell defaults to Marlin W4A16; whether Marlin is the right kernel for these large prefill row-counts (vs a grouped-GEMM path) is an open question, and the natural large-batch MoE lever — DeepGEMM W4A8 — is currently blocked on SM120, so near-term it's Marlin or bust there. Since the step is comms-bound, the one no-rebuild knob worth a cheap A/B is NCCL tuning (algo/proto/channels for the fabric) — it won't touch kernel time but can shave the collective floor.

On max_num_partial_prefills — your thrash instinct is right, for throughput. A single 536K prefill already saturates both the GPU and the fabric, so running two concurrently adds no compute and no bandwidth — it splits the same resource, each prefill runs ~2× slower, and total wall-clock is ~unchanged (worse once the accumulated-context top-k runs for both). p50 for the first submitter degrades, the tail stays flat; serialize-at-1 is actually protecting mean TTFT. What raising it would help is a different goal — fairness for small requests stuck behind a big prefill — and the better lever there is chunked-prefill interleaving + long_prefill_token_threshold to cap the big prefill's per-step budget so small requests slip between its chunks. The catch you already named applies: the indexer top-k is over accumulated context, so every late chunk still pays ~full-context indexer cost → chunking makes the big prefill itself slower while unblocking the small ones (a fairness-vs-big-prefill-throughput trade, tunable via the threshold). It doesn't move the single-prefill floor — only the kernel does, and that (as you noted) helps single-request TTFT and the parallel stall equally.

So: nsys next. A single-request 1M/536K prefill GPU timeline decides whether the effort goes to the prefill-MLA kernel, the MoE GEMM, or the comms floor — grab it whenever the maintenance window opens and we'll target from there.

Flip VLLM_DEEPSEEK_V4_FLASHINFER_SM120_DECODE default False -> True. On SM12x
with FlashInfer >= 0.6.14 present, the DSv4 attention selector now routes
prefill + decode through the FlashInfer SM120 packed sparse-MLA kernel
(trtllm_batch_decode_sparse_mla_dsv4) instead of our Triton/TileLang FlashMLA
path. The selector still gates on SM12x + has_flashinfer_trtllm_sparse_mla_dsv4(),
so non-SM12x and FI-missing boxes fall back to Triton unchanged; set the flag =0
to force the Triton FlashMLA path.

A/B on FI 0.6.14 (GB10 2-node TP=2, build 616a572):
  - 8-32k: ~parity (no regression)
  - ctx_pp (prefill):  +12.5% @128k, +18.1% @256k
  - pp2048 (prefill):  +15%   @128k, +16%   @256k
  - tg128 (decode):    +45%   @128k, +81%   @256k  (Triton falls to 14 t/s,
    FI holds 25 t/s at 256k); both widen with context; 256k TTFT 248s -> 210s
Correctness on the FI path: arthur 434k 2/2 + GSM8K 0.975 (== Triton). Triton
FlashMLA stays the instant fallback.
@jedelacarrera

Copy link
Copy Markdown

@epheien Thanks for the report.

I traced this to the strict tool-calling structural-tag grammar for DeepSeek V4 chat-mode outputs. In your log, the rejected token sequence [16, 128822] decodes as .</think>. The prompt in chat mode already ends the thinking section, but the model can occasionally emit an extra </think> before entering normal content/tool-call output. After the rebase, xgrammar's DeepSeek V4 non-reasoning structural tag excluded </think>, so the scheduler treated that otherwise harmless extra close tag as a grammar violation and terminated the request.

I pushed a narrow fix in 531807c34bf73f6ef095f203486e12dadfbdadb2: for DeepSeek V4 non-reasoning strict tool-calling only, allow a stray </think> while still rejecting <think> and leaving the reasoning-mode structural tag unchanged.

Verified with:

  • tests/tool_parsers/test_structural_tag_registry.py -q
  • tests/tool_parsers/test_deepseekv4_tool_parser.py tests/reasoning/test_deepseekv4_reasoning_parser.py -q
  • an xgrammar matcher check for .</think> and .</think><|DSML|tool_calls>\n

Please rebuild/redeploy the latest PR head and retry. If it still reproduces, please share the launch args around --tool-call-parser, --reasoning-parser, VLLM_ENFORCE_STRICT_TOOL_CALLING, and MTP/spec decode settings.

@jasl Following up on the strict tool-calling grammar thread above — we hit what looks like a different xgrammar termination on a branch head that already includes 531807c34: with MTP spec decode enabled, named-tool_choice requests with thinking on intermittently 500.

Setup: 2× RTX PRO 6000 workstation (sm_120), TP=2, commit 616a5723ac, DeepSeek-V4-Flash-DSpark.
Launch args: --kv-cache-dtype=fp8 --max-model-len=32768 --enable-auto-tool-choice --tool-call-parser=deepseek_v4 --reasoning-parser=deepseek_v4 --speculative-config={"method": "deepseek_mtp", "num_speculative_tokens": 3}. VLLM_ENFORCE_STRICT_TOOL_CALLING unset.

Symptom: in a 42-request structured-extraction batch (~8k-token prompts, concurrency 10), 7/42 returned 500 Internal server error (~38 s server-side), correlated with bursts of:

/project/cpp/grammar_matcher.cc:612: Warning: The matcher has terminated after accepting the stop token, but is trying to accept new token with id 128825.

Unlike the June report, the rejected ids vary per request (128825, 119061, 112434, 1, …) — it reads like draft tokens still being proposed after the matcher accepted the stop token, not a fixed stray tag.

Triangulation (same workload): MTP off + thinking on → 42/42 OK. MTP on + thinking off (chat_template_kwargs) → 42/42 OK (warnings still logged, requests succeed). MTP on + thinking on → 7/42 → 500. Not reproducible with a short single request — appears load/length-dependent.

Workaround we're shipping: thinking off whenever a function is forced via tool_choice (no exact-match loss on our extraction evals). Happy to provide full logs or test a patch. Otherwise the branch has been excellent on sm_120 — MTP alone takes us 85 → 151 tok/s single-stream.


Disclosure: I'm not an expert in AI hardware or vLLM internals — this report was put together with extensive help from Claude Code. Numbers and logs are from real runs; the root-cause reading may be off, happy to be corrected.

@jasl

jasl commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

@jedelacarrera I will check tomorrow, thank you for the report!

…rammar advance at the reasoning boundary (vllm-project#44297)

Signed-off-by: Allen.Yu <yuyue0225sc@163.com>
Signed-off-by: yue.yu <yuyue0225sc@163.com>
Co-authored-by: Benjamin Chislett <chislett.ben@gmail.com>
(cherry picked from commit e7c9df9)
@jasl

jasl commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

@jedelacarrera Thank you — this was an excellent report, and your instinct was right on two counts: the workaround is sound, and the grammar_matcher.cc … terminated warning was a red herring (it fires in the passing case too).

Reproduced on a 2-node GB10 (TP=2) with your configuration — MTP + --enable-auto-tool-choice --tool-call-parser deepseek_v4 --reasoning-parser deepseek_v4, thinking on, forced tool_choice, ~8k-token prompts @ concurrency 10: 16/42 → 500. It's an upstream structured-output × spec-decode issue and is independent of hardware (the buggy code lives in the scheduler / structured-output manager), so it applies to your sm120 setup as well.

Root cause (not the terminated matcher): every 500 is scheduler.py … Unexpected: grammar rejected tokens … Terminating request, and the rejected token lists decode to the reasoning tail + </think> — e.g. [270, 4105, 16, 128822] = " the tool.</think>". Under speculative decoding, when the reasoning stream ends inside a single MTP step, the scheduler was advancing the tool-call structural-tag grammar over the pre-</think> reasoning tokens; the grammar correctly rejects them and the request is terminated. That's exactly why it's thinking-on + MTP-on only: thinking-off has no reasoning tokens to mis-feed, and non-MTP never straddles the boundary within a single step.

This is already fixed upstream by #44297 ("Constrain bitmask and trim grammar advance at the reasoning boundary"). I've backported it to this branch (codex/ds4-sm120-min-enable / ds4-sm120-preview-dev, commit fe486bf6). It's a pure-Python change — no kernel rebuild required.

Validated on the same box: the identical 42-request workload → 42/42, 0×500, 0 terminated requests; and #44297's own test suite (tests/v1/spec_decode/test_mtp_structured_output.py, incl. test_bitmask_constrained_when_reasoning_ends_midwindow) passes on our hardware.

Please pull the latest branch head and retry. Your thinking-off-under-forced-tool_choice workaround is a perfectly good stopgap in the meantime. Thanks again — the decoded token ids and the MTP/thinking triangulation in your report made this quick to pin down.

One heads-up: after #44297 you may still see a few non-fatal Failed to advance FSM … Please file an issue. log lines under this workload — that's an expected, tolerated artifact of #44297's bitmask-simulation path (it probes post-reasoning drafts and tolerates rejection, but accept_tokens still logs). Requests succeed; it's cosmetic only.

Picks up vllm-project#47474 (dsv4 token_to_req_indices caching, 5-6x kernel) + 101 other
upstream commits. 3 conflicts resolved keeping our validated DSv4 stack:
- reasoning: keep our DeepSeekV4ThinkingReasoningParser (DSML implicit-</think>
  end-marker fix); do not adopt upstream's new vllm.parser.engine reasoning
  adapter for deepseek_v4 (separate validated follow-up if wanted).
- dspark.py: keep our extended DeepSeekV4DSpark (markov draft + V1-optin);
  upstream's small _insert_context_kv/ForCausalLM refactor does not apply.
@jedelacarrera

Copy link
Copy Markdown

@jedelacarrera Thank you — this was an excellent report, and your instinct was right on two counts: the workaround is sound, and the grammar_matcher.cc … terminated warning was a red herring (it fires in the passing case too).

Reproduced on a 2-node GB10 (TP=2) with your configuration — MTP + --enable-auto-tool-choice --tool-call-parser deepseek_v4 --reasoning-parser deepseek_v4, thinking on, forced tool_choice, ~8k-token prompts @ concurrency 10: 16/42 → 500. It's an upstream structured-output × spec-decode issue and is independent of hardware (the buggy code lives in the scheduler / structured-output manager), so it applies to your sm120 setup as well.

Root cause (not the terminated matcher): every 500 is scheduler.py … Unexpected: grammar rejected tokens … Terminating request, and the rejected token lists decode to the reasoning tail + </think> — e.g. [270, 4105, 16, 128822] = " the tool.</think>". Under speculative decoding, when the reasoning stream ends inside a single MTP step, the scheduler was advancing the tool-call structural-tag grammar over the pre-</think> reasoning tokens; the grammar correctly rejects them and the request is terminated. That's exactly why it's thinking-on + MTP-on only: thinking-off has no reasoning tokens to mis-feed, and non-MTP never straddles the boundary within a single step.

This is already fixed upstream by #44297 ("Constrain bitmask and trim grammar advance at the reasoning boundary"). I've backported it to this branch (codex/ds4-sm120-min-enable / ds4-sm120-preview-dev, commit fe486bf6). It's a pure-Python change — no kernel rebuild required.

Validated on the same box: the identical 42-request workload → 42/42, 0×500, 0 terminated requests; and #44297's own test suite (tests/v1/spec_decode/test_mtp_structured_output.py, incl. test_bitmask_constrained_when_reasoning_ends_midwindow) passes on our hardware.

Please pull the latest branch head and retry. Your thinking-off-under-forced-tool_choice workaround is a perfectly good stopgap in the meantime. Thanks again — the decoded token ids and the MTP/thinking triangulation in your report made this quick to pin down.

One heads-up: after #44297 you may still see a few non-fatal Failed to advance FSM … Please file an issue. log lines under this workload — that's an expected, tolerated artifact of #44297's bitmask-simulation path (it probes post-reasoning drafts and tolerates rejection, but accept_tokens still logs). Requests succeed; it's cosmetic only.

@jasl Confirmed on sm_120 (2× RTX PRO 6000, TP=2): cherry-picked fe486bf’s three files onto our 616a572 build — the previously-failing workload (thinking on, named tool_choice, MTP, ~8k prompts) now passes clean: [42/42, 0×500 / 10/10 smoke]. One note for others on this path: the other commit on the branch head (30283b9, FlashInfer SM120 default) requires a newer FlashInfer with flashinfer.mla._sparse_mla_sm120 — pulling the full head onto an older image crashes at model load; either update FlashInfer or take #44297’s files only. Thanks for the fast turnaround!

@wingcomm

wingcomm commented Jul 6, 2026

Copy link
Copy Markdown

nsys GPU-kernel trace — long-context prefill (the gating measurement)

Build: 9f18be7 (PR #41834), 1M ctx, TP4/EP4, packed FlashInfer SM120 prefill+decode, GB10.
Captures: two single-request prefills, max_tokens=1, unique prompts (no prefix-cache hit),
traced with nsys --trace=cuda wrapping the worker on rank0's GB10. Per-kernel GPU durations
are exact
under nsys (overhead is CPU-launch-side); wall-clock inflated but irrelevant to the
% split. GPU stays ≈99.7% busy in both — the fabric is not the wall here.

Headline

Long-context prefill is GPU-COMPUTE-bound (GPU ≈99.7% busy), NOT comms-bound. The dominant
kernel is the lightning-indexer's fp8 MQA-logits — and its share GROWS with context.

GPU-kernel time breakdown (rank0), two context sizes

bucket ~200K-token req ~536K-token req (window at higher accumulated ctx)
INDEXER — fp8 MQA-logits (_fp8_mqa_logits_kernel) 52.5% 59.3%
MoE (Marlin W4A16 + block-mm + einsum + act) 14.8% 11.4% ↓
sparse-MLA prefill attention (sparse_mla_prefill_mg_dual_kernel) 8.8% 9.0%
INDEXER — top-k / compress (topKPerRowPrefill<512>) 5.0% 5.7%
COMMS (NCCL all-reduce, RING_LL) 7.5% 5.4%
MHC prenorm GEMM (TileLang) 6.2% 5.1%
elementwise / copy / quant 3.7% 2.9%
RoPE / RMSNorm 1.5% 1.1%
GPU busy 99.7% 99.6%

Combined lightning-indexer (MQA-logits + top-k + compress): 57.5% → 65.0% as context grows.

What this settles

  1. Your py-spy caveat was right to flag — but it isn't comms-inflation. The "~50% MLA"
    py-spy bucket is the indexer MQA-logits kernel (52–59%), real GPU compute, not NCCL-wait.
    Comms is only 5–7% of GPU time and the GPU is 99.7% busy → prefill is genuinely
    compute-bound. (Decode stays the interconnect-latency-bound regime; prefill is not.)
  2. The scaling is the story. From 200K→536K accumulated context the indexer's share rises
    (52.5%→59.3%) while MoE (14.8%→11.4%) and comms (7.5%→5.4%) shrink. That's the O(context)
    fingerprint: the indexer cost grows ~linearly with accumulated context, everything else is
    ~constant per token. This is exactly why single-request TTFT grows ~linearly with prompt
    length AND why parallel big prefills stall (each late chunk re-scans ≈full context).
  3. The actual sparse-MLA attention is only ~9% — the packed FlashInfer SM120 prefill path
    (PREFILL=1) already keeps it cheap. The bottleneck is upstream, in the indexer.
  4. Highest-leverage target: _fp8_mqa_logits_kernel. Is there a packed/FlashInfer or fused
    indexer path for prefill, analogous to the SM120 decode indexer? That single kernel is
    59% of a 536K prefill and rising — it moves single-request TTFT and the parallel stall
    together, far more than attention or MoE.
  5. NCCL tuning has low prefill upside (comms 5–7%, falling with context). I applied multi-QP
    (NCCL_IB_QPS_PER_CONNECTION=4) and am A/B-ing it, but the trace says the fabric isn't the
    prefill wall — it's the indexer. (Multi-QP may still help decode, which is comms-bound.)

Raw .nsys-rep traces (200K: 13 MB, 536K: 8 MB) available on request.

jasl added 2 commits July 7, 2026 09:11
…-enable

# Conflicts:
#	vllm/models/deepseek_v4/attention.py
…rnel)

Upstream pins flashinfer-python/cubin==0.6.13, but PyPI 0.6.13 does not ship
flashinfer.mla._sparse_mla_sm120 (the DeepSeek-V4 SM120 sparse-MLA decode
kernel our fork's FlashInferMLASparseSM120 backend calls). Pin 0.6.14, which
carries it; flashinfer-cubin 0.6.14 is GitHub-release-only (not on PyPI), so
reference the release wheel directly. Prevents 'pip install -e .' from silently
reverting a hand-installed 0.6.14 back to 0.6.13 on every rebuild.
@jasl

jasl commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

@wingcomm This is a great trace — a per-kernel nsys split is exactly the measurement that settles the py-spy ambiguity, thank you. It lines up with what we've been seeing and sharpens it in one important way.

On the attribution: our earlier py-spy had bucketed that ~52% as "MLA," but your nsys resolves it correctly — it's the lightning-indexer _fp8_mqa_logits_kernel, and the actual sparse-MLA prefill attention (sparse_mla_prefill_mg_dual_kernel) is only ~9%. That matches our own kernel-level view: the indexer scoring is the O(N²) residual; the packed FlashInfer SM120 prefill path already keeps the attention itself cheap. Your O(context) scaling (52→59% as accumulated ctx grows, MoE/comms shrinking) is the N×N all-pairs signature — attention is ~O(N·d) per token, the indexer is the one that grows.

On your headline ask — a packed/fused prefill indexer, analogous to the SM120 decode indexer: we chased exactly this, and it looks like a hard floor recall-safely, so I'll save you the detour. The decode indexer packs because per step it scores 1×N; prefill is N×N. The sub-quadratic angles we tried all broke recall or were vacuous:

  • interval/range-bound pruning → vacuous (≈0% pruned at the recall we need),
  • cross-layer top-k reuse → no-go (top-512 overlap only ~38% across layers),
  • pooled / block-sparse scoring → recall collapse (0.25–0.39; the pooled keys end up too cos-similar),
  • MISA-dagger-style → only ~1.5×, and exact-only.

The kernel itself is already fp8 + tuned and compute-bound (not bandwidth) — fusing the top-k into it is only ~4%. So the realistic prefill wins there are constant-factor, not complexity; the O(N²) is inherent to top-k-selecting over the full context.

On NCCL: agreed, low prefill upside — comms at 5–7% and falling is consistent with what we see (prefill is compute-bound, the fabric isn't the wall). Decode is the other story: that is the interconnect-latency-bound regime for us on 2-node, so I'd be very interested in how your multi-QP (NCCL_IB_QPS_PER_CONNECTION=4) A/B lands on decode specifically — if it moves decode, that's a real no-rebuild lever.

Would love the .nsys-rep traces (both sizes) if you don't mind sharing — the exact per-kernel timings are useful to diff against our own captures. Thanks again, this is high-signal.

VLLM_DEEPSEEK_V4_FLASHINFER_SM120_DECODE has defaulted True since the flip
(envs.py:198); the two docstrings still said 'Default off'. Doc-only, no
runtime change. (The >=0.6.13 RuntimeError message is correct — that's the
trtllm_batch_decode_sparse_mla_dsv4 / PR3395 minimum — left as-is.)
@wingcomm

wingcomm commented Jul 7, 2026

Copy link
Copy Markdown

Thanks — and thank you for the indexer floor analysis; that saves us a real detour. Agreed
it's the N×N wall: top-k over the full accumulated context is inherent, and if interval-prune
goes vacuous / cross-layer overlap is only ~38% / pooled scoring collapses recall, there's no
sub-quadratic path that keeps recall. We'll treat long-context prefill (and the parallel-prefill
stall) as a fundamental cost and stop hunting a kernel rescue for it. Constant-factor only, as
you say.

On the multi-QP decode A/B you asked for — it's neutral. No measurable movement.

Controlled decode benchmark (short prompt, 128 output tokens, temp 0.7, median of 3 trials/conc)
on the live 4-node/1M build, NCCL_IB_QPS_PER_CONNECTION=4 ON vs the base recipe OFF:

concurrency per-req tok/s (ON → OFF) median ITL ms (ON → OFF)
1 49.7 → 50.8 44.0 → 43.4
4 37.4 → 37.5 58.7 → 58.3
8 37.0 → 37.2 58.5 → 58.1

If anything OFF is a hair faster at conc-1, but it's inside trial noise — call it a wash. The
reason lines up with the trace's story: decode ITL here is small-message all-reduce
RTT-latency-bound, and multi-QP is a bandwidth lever
, so it has nothing to bite on. Also worth
noting — NCCL is already auto-selecting RING_LL for these collectives (visible in the prefill
trace as ncclDevKernel_AllReduce_Sum_bf16_RING_LL), so the low-latency proto is already in play;
forcing LL128 is largely moot. Net: NCCL knob-tuning isn't the no-rebuild decode lever on this
fabric.
I've reverted multi-QP off (kept the config minimal).

The decode numbers for reference: ~50 tok/s/req at conc-1 (ITL ~44 ms, MTP giving ~2 tok/step),
flattening to ~37 tok/s/req by conc-4–8 (ITL ~58 ms) as the per-step collective contends. The
real decode levers that remain look like MTP k↑ (more tokens per fixed-latency collective —
you've got the accept-rate headroom) or lower TP per replica (fewer collectives/token) — not
NCCL flags. Curious whether that matches your 2-node view.

Attaching the full per-kernel GPU-time tables (cuda_gpu_kern_sum, 108 kernels each) for both
sizes as CSV — that's the exact per-kernel timing to diff against your own captures. I left the
raw .nsys-rep out of the public thread since nsys bakes our internal fabric addresses into the
captured command line; if you want the full GUI timeline, say the word and I'll get you a scrubbed
copy or send it your way directly.

`=== multi-QP ON (NCCL_IB_QPS_PER_CONNECTION=4) — decode benchmark, 3 trials/conc ===
run: 2026-07-07 00:38:49
conc=1 ntok=128: per-req 49.52 tok/s | median ITL 43.7 ms | aggregate 47.02 tok/s | n=1
conc=1 ntok=128: per-req 49.37 tok/s | median ITL 44.1 ms | aggregate 47.02 tok/s | n=1
conc=1 ntok=128: per-req 50.27 tok/s | median ITL 44.0 ms | aggregate 47.72 tok/s | n=1
conc=4 ntok=128: per-req 36.92 tok/s | median ITL 58.7 ms | aggregate 80.57 tok/s | n=4
conc=4 ntok=128: per-req 37.43 tok/s | median ITL 58.6 ms | aggregate 79.20 tok/s | n=4
conc=4 ntok=128: per-req 37.87 tok/s | median ITL 58.7 ms | aggregate 81.58 tok/s | n=4
conc=8 ntok=128: per-req 36.89 tok/s | median ITL 58.9 ms | aggregate 93.92 tok/s | n=8
conc=8 ntok=128: per-req 37.41 tok/s | median ITL 58.5 ms | aggregate 95.53 tok/s | n=8
conc=8 ntok=128: per-req 36.68 tok/s | median ITL 58.2 ms | aggregate 93.38 tok/s | n=8

=== multi-QP OFF (base recipe, no QP line) — decode benchmark, 3 trials/conc ===
run: 2026-07-07 00:43:59
conc=1 ntok=128: per-req 52.54 tok/s | median ITL 43.4 ms | aggregate 49.59 tok/s | n=1
conc=1 ntok=128: per-req 49.88 tok/s | median ITL 43.4 ms | aggregate 47.22 tok/s | n=1
conc=1 ntok=128: per-req 50.00 tok/s | median ITL 43.3 ms | aggregate 47.45 tok/s | n=1
conc=4 ntok=128: per-req 37.01 tok/s | median ITL 58.7 ms | aggregate 78.17 tok/s | n=4
conc=4 ntok=128: per-req 38.52 tok/s | median ITL 57.6 ms | aggregate 80.71 tok/s | n=4
conc=4 ntok=128: per-req 36.98 tok/s | median ITL 58.5 ms | aggregate 80.31 tok/s | n=4
conc=8 ntok=128: per-req 37.36 tok/s | median ITL 57.9 ms | aggregate 65.44 tok/s | n=8
conc=8 ntok=128: per-req 38.54 tok/s | median ITL 58.5 ms | aggregate 95.26 tok/s | n=8
conc=8 ntok=128: per-req 35.84 tok/s | median ITL 58.0 ms | aggregate 92.84 tok/s | n=8

=== VERDICT: multi-QP (NCCL_IB_QPS_PER_CONNECTION=4) is NEUTRAL on decode ===
per-req tok/s median ITL (ms)
conc ON OFF ON OFF
1 49.7 50.8 44.0 43.4 (OFF marginally faster; within noise)
4 37.4 37.5 58.7 58.3 (indistinguishable)
8 37.0 37.2 58.5 58.1 (indistinguishable)
Conclusion: no measurable decode benefit. Decode ITL is small-message all-reduce
RTT-latency-bound; multi-QP is a bandwidth lever -> doesn't move it. NCCL already
auto-selects RING_LL (low-latency proto) for these small collectives. Kept multi-QP
OFF (base recipe) as live config. Real decode levers = MTP k-up (more tokens/collective)
or lower TP per replica, NOT NCCL knobs. Prefill separately confirmed compute-bound
(indexer O(N^2)) by nsys, comms 5-7% -> multi-QP no prefill upside either.`

dsv4-9f18be76-prefill-200k-kernels.csv
dsv4-9f18be76-prefill-536k-kernels.csv

@mergify

mergify Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @jasl.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status
Status: No status
Status: No status

Development

Successfully merging this pull request may close these issues.